Repository navigation
feat(trainer): async checkpointing with pinned staging (supersedes #4331) - #4428
Open
faresobeid wants to merge 4 commits into
Open
faresobeid wants to merge 4 commits into
faresobeid wants to merge 4 commits into
Conversation
ckpt.async_save copies each rank's state into reused pinned host buffers and writes them from a background thread on a dedicated gloo group. Without optimizer offload the copy overlaps the next step and is fenced before its optimizer step; with optimizer offload the CPU state is copied in place (no GPU round trip) before the save returns. Resume skips trainer checkpoints without DCP .metadata, keep_last only counts finished writes, and the final save and control-API checkpoint actions wait for the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run the DCP write in our own single-thread executor bound to the rank's GPU. A thread's current device starts at 0, so DCP's thread writer created a CUDA context on GPU 0 for every rank (+3.7 GiB there). The staging copy is now tracked by a CUDA event instead of a waiter thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
faresobeid
marked this pull request as ready for review
November 8, 2026 16:14
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit aa5de9e. Configure here.
…e steps for cleanup The background write closed over the `staging` name, which the offload path sets to None once staging is done: a write that started after that read None. It now gets the staging as an argument. CheckpointManager seeded keep_last from every step dir, including a trainer checkpoint cut off mid-write before a resume, which could displace the complete step the run resumed from. It now seeds only complete steps (is_ckpt_step_complete, as resume does). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Async trainer checkpoint saves for every
model.offloadmode. This supersedes #4331 and builds on it.What it does
ckpt.async_save = true(--ckpt.async-saveforsft;--ckpt.async-saveor--trainer.ckpt.async-saveforrl; default off). A save copies each rank's model and optimizer state into pinned host buffers and returns. A background thread writes the copy with DCP while training continues.Design
PinnedStager). Each tensor in the state dict is copied into a pinned host buffer. Buffers are keyed by the tensor's path and reused across saves, so pinning happens on the first save only. Later staging calls take about 130 ms. DTensors are staged through their local shard and rewrapped with their spec. DCP'sDefaultStagercannot stage this trainer's DTensors ("invalid python storage", also hit in feat(trainer): async DCP checkpoint saves #3874). Non-tensor values are deep-copied.model.offload = "none"). The GPU→host copy runs on a side stream, overlapping the next step's forward and backward. The source tensors are kept alive until it finishes.ckpt_manager.wait_for_staging()right beforeoptimizer.step()in the RL and SFT loops. In every run here it waited 0 ms.optimizer/full). The state dict reads the CPU optimizer state where it lives. Unlike a synchronous save, it skipsprepare_checkpoint_save's CPU→GPU→CPU round trip; this is feat(trainer): async checkpoint saves with CPU optimizer offload #4331's_build_state_dictpath, including its GPU detour for fused entries split along their sharded dim. The CPU state is then copied into the pinned buffers beforesavereturns.fullupdates it during backward), and the copy means a write longer than one step never stalls the next step.dcp.saveon a dedicated gloo group, created on the first async save. Each write is submitted with its staging object as an argument.get_dcp_group()is cached and also serves the main thread's barriers and loads./dev/shmthe size of the shard.savefirst waits for the previous write.keep_last/keep_intervalcount a step only once its write has finished (credit feat(trainer): async checkpoint saves with CPU optimizer offload #4331), and at startup count only complete steps (see Completeness), so cleanup never deletes the newest finished checkpoint for a write in flight or for a step cut off mid-write before a resume.wait(). The final save waits for its write before exit. The control API'scheckpointaction now waits beforemark_saved, so a committed checkpoint is on storage.trainer/.metadataafter every rank's shards.resolve_latest_ckpt_stepnow skips a step whose trainer checkpoint has none. Before this, a crash mid-write made--resumepick the partial step and fail indcp_load(reproduced on feat(trainer): async checkpoint saves with CPU optimizer offload #4331 below). The trainer, the orchestrator and the launchers all resolve through this function, so they agree on the step.How it differs from #4331
#4331 passes the CPU optimizer state to the writer by reference and holds the next optimizer update until the write finishes. When a write takes longer than a step, the step after each save stalls for the rest of the write. With the settings below, that is ~24 s per save, half of a sync save. It also only supports CPU optimizer offload, and it has no resume-side completeness check.
This PR keeps #4331's fence placement and
keep_lasthandling, and its offloaded-state read path. It adds:offload = "none"support, with the overlapped copy and fence;Cost
optimizer: sync +14 GiB, feat(trainer): async checkpoint saves with CPU optimizer offload #4331 +33 GiB, this PR +49 GiBnone: sync +27 GiB, this PR +46–47 GiBnone). PyTorch's allocated and reserved memory are unchanged.optimizer, where feat(trainer): async checkpoint saves with CPU optimizer offload #4331 shows 23.6). The cause was the extra CUDA contexts on GPU 0, not staging buffers or allocator fragmentation.Results
The equality, resume, kill and timing runs below used the first commit, where DCP's thread writer did the writes. After switching to the device-bound writer thread, I re-ran MoE equality (both offload modes, byte-identical), memory, and offload-
nonetiming (save step 26.35 ± 1.91 s, step after save 25.1 s, peak GPU 21.4 GiB).1 node, 8×H200, SFT trainer with fake data, checkpoints on node-local disk (~0.4 GB/s, so a 20 GB write takes ~48 s).
Equality. A synchronous reference copy of the same state was saved right before the async save, then compared chunk by chunk:
Resume. Resuming from the async step-4 checkpoint and from the sync copy gives losses identical to the uninterrupted run at steps 5–8:
Kill mid-write. Async saves every step. Every trainer process was SIGKILLed while
step_4/trainerhad no.metadata.FileNotFoundError: …/step_4/trainer/.metadata).Step time. Qwen3-1.7B, batch 256 (~25 s steps),
ckpt.interval = 5, 16 steps, mean over saves at steps 5/10/15:The first step after the first save is slower (28 s) while the pinned buffers are allocated and registered.
ruff check/ruff formatare clean.tests/unit/utils/test_pathing.pypasses, including the new test.tests/unit/test_configs.pyhas the same failures as onmain(taskset packages missing in my environment).tests/unit/train/test_state_offload.pyandtests/unit/train/test_ckpt.pypass on GPU (7 passed). Two of those tests fail on the code before the fix:test_offloaded_async_save_writes_after_it_returned(an offloaded async write that starts aftersavereturned) andtest_keep_last_ignores_a_step_cut_off_mid_write.🤖 Generated with Claude Code
Note
Medium Risk
Changes checkpoint I/O and training-step synchronization on a critical resume path; mitigated by completeness checks, explicit fences, and targeted tests, but adds pinned RAM and background-thread failure modes.
Overview
Adds optional asynchronous trainer checkpoint saves via
ckpt.async_save(--ckpt.async-saveon SFT/RL). Instead of blocking on DCP writes, saves copy model and optimizer state into reused pinned host buffers on a side CUDA stream, then a background thread persists the staged state while training continues.CheckpointManagergainsPinnedStager(DTensor-aware), single in-flight save semantics,wait_for_staging()before the optimizer step, andwait()for durability. Offloaded optimizer paths stage CPU state without the sync save’s GPU round trip; withmodel.offload = "none", copy overlaps the next forward/backward until the staging fence. Resume and retention now treat checkpoints as complete only whentrainer/.metadataexists (is_ckpt_step_complete), so mid-write crashes no longer win--resumeor get pruned bykeep_last.RL and SFT loops call the new fences; control-API checkpoint actions wait for storage before
mark_saved. Config propagation and docs document behavior, host-memory cost, and failure handling. Unit/GPU tests cover incomplete-step cleanup, latest-step resolution, and offloaded async writes.Reviewed by Cursor Bugbot for commit 97282c0. Bugbot is set up for automated code reviews on this repo. Configure here.